#! /usr/bin/env lua-any
-- Lua-Versions: 5.4
--[[## ft=lua ##################################################################
  This file is licensed under [WTFPL v2](https://www.wtfpl.net/).  You may read
  the full license in the file LICENSE or by following the web link.
--############################################################################]]

--[[************************************
  configure the lester testing suite
--[[**********************************]]
local lester = require 'lester'
local describe, it, expect, before, after = lester.describe, lester.it, lester.expect, lester.before, lester.after
lester.utf8term = true
lester.parse_args()

--[[####################################
  helper functions
--####################################]]

local function count_keys(t)
    local count = 0
    for _,_ in pairs(t) do
        count = count + 1
    end
    return count
end

--[[####################################
  fixed tests
--####################################]]
describe('fixed', function()
    local fixed

    before(function()
        fixed = require 'fixed'
    end)

    after(function()
        package.loaded['fixed'] = nil
        fixed = nil
    end)

    it('creates types as tables', function()
        local q15_1 = fixed(15, 1)
        expect.truthy(type(q15_1) == 'table')
        local q31_1 = fixed(31, 1)
        expect.truthy(type(q31_1) == 'table')
        local q27_5 = fixed(27, 5)
        expect.truthy(type(q27_5) == 'table')
    end)

    it('converts types from floats to fixed', function()
        local v1 = fixed(15, 1)(1.5)
        expect.equal(getmetatable(v1).__type__,'q15.1')
        expect.equal(string.format('%x, %f', v1._v, v1()), '3, 1.500000')
        expect.equal(v1(), 1.5)

        local v2 = fixed(31, 1)(0.5)
        expect.equal(getmetatable(v2).__type__, 'q31.1')
        expect.equal(string.format('%x, %f', v2._v, v2()), '1, 0.500000')
        expect.equal(v2(), 0.5)

        local v3 = fixed(27, 5)(1.25)
        expect.equal(getmetatable(v3).__type__, 'q27.5')
        expect.equal(string.format('%x, %f', v3._v, v3()), '28, 1.250000')
        expect.equal(v3(), 1.25)

        local v4 = fixed(27, 5)(1.8)
        expect.equal(getmetatable(v4).__type__, 'q27.5')
        expect.equal(string.format('%x, %f', v4._v, v4()), '3a, 1.812500')
        expect.not_equal(v4(), 1.8)
    end)

    it('handles arithmetic operations', function()
        local q8_4 = fixed(8, 4)
        local a = q8_4(1.5)
        local b = q8_4(2.25)

        function expect.within(n1, n2, r)
            expect.truthy(math.abs(n1 - n2) <= r)
        end

        expect.equal((a + b)(), 3.75)
        expect.equal((a + 2.25)(), 3.75)
        expect.equal((1.5 + b)(), 3.75)

        expect.equal((a - b)(), -0.75)
        expect.equal((a - 2.25)(), -0.75)
        expect.equal((1.5 - b)(), -0.75)

        expect.within((a * b)(), 3.375, 0.01)
        expect.within((a * 2.25)(), 3.375, 0.01)
        expect.within((1.5 * b)(), 3.375, 0.01)

        expect.within((a / b)(), 0.666, 0.022)
        expect.within((a / 2.25)(), 0.666, 0.022)
        expect.within((1.5 / b)(), 0.666, 0.022)

        expect.within((a ^ b)(), 2.4900343193257, 0.01)
        expect.within((a ^ 2.25)(), 2.4900343193257, 0.01)
        expect.within((1.5 ^ b)(), 2.4900343193257, 0.01)
    end)

    it('handles comparisons', function()
        local q8_4 = fixed(8, 4)
        local a = q8_4(1.5)
        local b = q8_4(2.25)

        expect.truthy(a < b)
        expect.truthy(a <= b)
        expect.truthy(b > a)
        expect.truthy(b >= a)
        expect.truthy(a == q8_4(1.5))

        expect.truthy(a < 2.25)
        expect.truthy(a <= 2.25)
        expect.truthy(2.25 > a)
        expect.truthy(2.25 >= a)
    end)

    it('detects overflow and saturates', function()
        local q4_4 = fixed(4, 4)
        local max_val = q4_4:max_value()
        local min_val = q4_4:min_value()

        -- invalid raw values should be rejected
        expect.fail(function() q4_4:from_raw(0x100) end, 'raw_value = 256, max_value = 127')

        -- build a value safely, then turn it wrong
        local overflow = q4_4:from_raw(0xff)
        overflow._v = 0x100
        expect.truthy(overflow:check_overflow())
        overflow:saturate()
        expect.equal(overflow._v, max_val._v)
    end)

    it('converts between different fixed-point formats', function()
        local q8_4 = fixed(8, 4)
        local q16_8 = fixed(16, 8)

        local a = q8_4(1.5)
        local b = q16_8(a)

        expect.truthy(math.abs(a() - b()) <= 0.001)
    end)

    it('handles rescaling', function()
        local q8_4 = fixed(8, 4)
        local a = q8_4(1.5)

        a:rescale(16, 8)
        expect.equal(a._m, 16)
        expect.equal(a._n, 8)
        expect.truthy(math.abs(a() - 1.5) <= 0.01)
    end)

    it('serializes and deserializes', function()
        local q8_4 = fixed(8, 4)
        local a = q8_4(1.5)
        local s = a:serialize()

        local b = q8_4:from_serialized(s)
        expect.equal(a._v, b._v)
    end)

    it('checks properties', function()
        local q8_4 = fixed(8, 4)
        local zero = q8_4(0)
        local positive = q8_4(1.5)
        local negative = q8_4(-1.5)

        expect.truthy(zero:is_zero())
        expect.truthy(positive:is_positive())
        expect.truthy(negative:is_negative())
    end)
end)

--[[####################################
  muban tests
--####################################]]
describe('muban', function()
    local muban

    before(function()
        muban = require 'muban'
    end)

    after(function()
        package.loaded['muban'] = nil
        muban = nil
    end)

    describe('basic operations', function()

        it('is a callable table with no entries', function()
            expect.truthy(type(muban) == 'table')
            expect.equal(count_keys(muban), 0)
            expect.not_fail(function()
                return muban('')
            end)
        end)

        it('returns a callable table', function()
            local t = muban('')
            expect.truthy(type(t) == 'table')
            expect.not_fail(function()
                return t {}
            end)
        end)

        it('generates empty text from empty template', function()
            local o = muban('')({})
            expect.equal(o, '')
        end)

    end)

    describe('block operations', function()

        it('handles {{= blocks', function()
            local v = muban('{{=foo}}')({foo='bar'})
            expect.equal(v, 'bar')
        end)

        it('handles {{! blocks', function()
            local v = muban('{{!for i = 1, 10 do}}b{{!end}}')({})
            expect.equal(v, 'bbbbbbbbbb')
        end)

        it('handles {{- blocks', function()
            local v = muban('{{-for i = 1, 10 do}}')({})
            expect.equal(v, '')
        end)

        it('handles {{# blocks', function()
            local f = io.open('foo.tmp', 'w+')
            f:write('baz')
            f:close()
            local v = muban('{{# foo.tmp }}')({})
            expect.equal(v, 'baz')
            os.remove('foo.tmp')
        end)

        it('handles escaped blocks', function()
            local v = muban('\\{{=foo}}')({foo='bar'})
            expect.equal(v, '{{=foo}}')
        end)

        it('handles double-escaped blocks', function()
            local v = muban('\\\\{{=foo}}')({foo='bar'})
            expect.equal(v, '\\bar')
        end)

        it('handles mixed escaped and unescaped tags', function()
            local v = muban('\\a\\{{=foo}}\\\\{{=foo}}')({foo='bar'})
            expect.equal(v, '\\a{{=foo}}\\bar')
        end)

    end)

    describe('error handling operations', function()
        local muban

        before(function()
            muban = require 'muban'
        end)

        after(function()
            package.loaded.muban = nil
        end)

        describe('template syntax errors', function()

            it('reports unclosed tags', function()
                local ok, err = pcall(muban, 'Hello \n\n\n{{=name')
                expect.falsy(ok)
                errs = tostring(err)
                expect.truthy(string.match(errs, 'unclosed template tag'))
                expect.truthy(string.match(errs, 'in .*:4'))
            end)

            it('reports unknown tag types', function()
                local ok, err = pcall(muban, 'Hello\n {{@name}}')
                expect.falsy(ok)
                errs = tostring(err)
                expect.truthy(string.match(errs, 'unknown tag'))
                expect.truthy(string.match(errs, 'in .string.:2'))
            end)

            it('permits user to supply file name', function()
                local ok, err = pcall(muban, 'Hello\n {{@name}}', {filename = 'main.tpl'})
                expect.falsy(ok)
                errs = tostring(err)
                expect.truthy(string.match(errs, 'in main.tpl:2'))
            end)

        end)

        describe('include handling', function()

            it('detects circular includes', function()
                -- Setup circular includes
                io.open('a.tpl', 'w'):write('{{#b.tpl}}'):close()
                io.open('b.tpl', 'w'):write('{{#a.tpl}}'):close()

                local ok, err = pcall(function() muban('{{#a.tpl}}')({}) end)
                expect.falsy(ok)
                local errs = tostring(err)
                expect.truthy(string.match(errs, 'circular include'))
                expect.truthy(string.match(errs, 'b.tpl.*a.tpl'))
                os.remove('a.tpl')
                os.remove('b.tpl')
            end)

            it('reports missing includes', function()
                local ok, err = pcall(function() muban('{{#missing.tpl}}', {filename = 'main.tpl'})({}) end)
                expect.falsy(ok)
                local errs = tostring(err)
                expect.truthy(string.match(errs, 'failed to open'))
                expect.truthy(string.match(errs, 'main.tpl'))
                expect.truthy(string.match(errs, 'missing.tpl'))
            end)

            --!!! Currently the inclusion chain and line tracking functionality is broken.
            it('shows include chain in errors', function()
                -- Setup include chain a→b→c with error in c
                local io = require 'io'
                io.open('a.tpl', 'w'):write('{{#b.tpl}}'):close()
                io.open('b.tpl', 'w'):write('{{#c.tpl}}'):close()
                io.open('c.tpl', 'w'):write('{{=missing}}'):close()

                local ok, err = pcall(function() muban('{{#a.tpl}}')({}) end)
                expect.falsy(ok)
                print(err)
                expect.truthy(string.match(tostring(err), 'runtime error.*missing'))
                expect.truthy(string.match(tostring(err), 'Include trace:'))
                expect.truthy(string.match(tostring(err), 'main.tpl:1.*a.tpl:1.*b.tpl:1.*c.tpl:1'))

                os.remove('a.tpl')
                os.remove('b.tpl')
                os.remove('c.tpl')
            end, false)
            --!!! Currently the inclusion chain and line tracking functionality is broken.

        end)

        describe('runtime errors', function()

            it('reports undefined variables', function()
                local ok, err = pcall(function() muban('{{=missing}}')({}) end)
                expect.falsy(ok)
                expect.truthy(string.match(tostring(err), 'runtime error.*missing'))
            end)

            it('reports syntax errors in code blocks', function()
                local ok, err = pcall(function() muban('{{! bad syntax }}')({}) end)
                expect.falsy(ok)
                expect.truthy(string.match(tostring(err), 'compile error'))
            end)
        end)

    end)

end)

--[[####################################
  pobject tests
--####################################]]
describe('pobject', function()

    local pobject

    before(function()
        pobject = require 'pobject'
    end)

    after(function()
        package.loaded['pobject'] = nil
        pobject = nil
    end)

    describe('prototype creation', function()
        local base

        before(function()
            base = { a = 1,  b = 2, c = 3,  d = 4, e = 5          }
        end)

        after(function()
            base = nil
        end)

        it('can create an empty prototype', function()
            local p = pobject()

            -- the prototype should not have any keys
            expect.equal(count_keys(p), 0)

            -- the prototype should have an empty locals table
            expect.exist(p:introspect().locals)
            expect.equal(count_keys(p:introspect().locals), 0)

            -- the prototype should not have a parent
            expect.not_exist(p:introspect().parent)

            p:destroy()
        end)

        it('can create a prototype from a base table', function()
            local p = pobject(base)

            -- the prototype should not have any keys
            expect.equal(count_keys(p), 0)

            -- the prototype should have a locals table
            expect.exist(p:introspect().locals)
            -- the prototype should not have a parent
            expect.not_exist(p:introspect().parent)

            -- the prototype's locals should not be the base
            expect.falsy(p:introspect().locals == base)

            -- the prototype's locals should match the base
            expect.equal(p:introspect().locals, base)

            p:destroy()
        end)

        it('should not change the base table when changing the prototype', function()
            local p = pobject(base)

            expect.equal(p:introspect().locals, base)
            p.a = 10
            expect.not_equal(p:introspect().locals, base)

            p:destroy()
        end)

        it('should not change the prototype when changing the base table', function()
            local p = pobject(base)

            expect.equal(p:introspect().locals, base)
            base.a = 10
            expect.not_equal(p:introspect().locals, base)

            p:destroy()
        end)

        it('should not change the base when adding to prototype', function()
            local p = pobject(base)

            expect.equal(p:introspect().locals, base)
            p.f = 60
            expect.not_exist(base.f)

            p:destroy()
        end)

        it('should not change the prototype when adding to base', function()
            local p = pobject(base)

            expect.equal(p:introspect().locals, base)
            base.f = 6
            expect.not_exist(p.f)

            p:destroy()
        end)

    end)

    describe('cloning operations', function()
        local base, child, grandchild

        before(function()
            base  =      { a = 1, b = 2, c = 3,    d = 4, e = 5,                    }
            child =      { a = 10,        c = 30,         e = 50, f = 60            }
            grandchild = {                c = 300,                f = 600 , g = 700 }
        end)

        after(function()
            base       = nil
            child      = nil
            grandchild = nil
        end)

        it('can create an identical clone', function()
            local p = pobject(base)
            local c = p:clone()

            -- the child should not have any keys
            expect.equal(count_keys(c), 0)

            -- the child should have an empty locals table
            expect.exist(c:introspect().locals)
            expect.equal(count_keys(c:introspect().locals), 0)

            -- the child should have a parent
            expect.exist(c:introspect().parent)

            -- the parent should match the prototype
            expect.equal(c:introspect().parent.locals, p:introspect().locals)

            p:destroy()
            c:destroy()
        end)

        it('clone should not modify parent on change', function()
            local p = pobject(base)
            local c = p:clone()

            c.a = 10
            expect.equal(c.a, 10)
            expect.equal(p.a, 1)

            -- check that the locals got updated
            expect.exist(c:introspect().locals)
            expect.equal(count_keys(c:introspect().locals), 1)


            p:destroy()
            c:destroy()
        end)

        it('parent should modify child on change', function()
            local p = pobject(base)
            local c = p:clone()

            p.a = 10
            expect.equal(p.a, 10)
            expect.equal(c.a, 10)

            -- check that the locals didn't get updated
            expect.exist(c:introspect().locals)
            expect.equal(count_keys(c:introspect().locals), 0)

            p:destroy()
            c:destroy()
        end)

        it('can create an extended clone', function()
            local p = pobject(base)
            local c = p:clone(child)

            -- the child should not have any keys
            expect.equal(count_keys(c), 0)

            -- the child should have a locals table with the child values
            expect.exist(c:introspect().locals)
            expect.equal(count_keys(c:introspect().locals), 4)
            expect.equal(c.a, 10)
            expect.equal(c.b, 2)
            expect.equal(c.c, 30)
            expect.equal(c.d, 4)
            expect.equal(c.e, 50)
            expect.equal(c.f, 60)

            expect.equal(p.a, 1)
            expect.equal(p.b, 2)
            expect.equal(p.c, 3)
            expect.equal(p.d, 4)
            expect.equal(p.e, 5)
            expect.not_exist(p.f)

            -- the child should have a parent
            expect.exist(c:introspect().parent)

            -- the parent should match the prototype
            expect.equal(c:introspect().parent.locals, p:introspect().locals)

            p:destroy()
            c:destroy()
        end)

        it('keeps cloning hierarchy separated from membership', function()
            local p = pobject()
            local c = p:clone()
            p.parent = c

            expect.equal(p.parent, c)

            expect.exist(p:introspect().locals)
            expect.exist(p:introspect().locals.parent)
            expect.not_exist(p:introspect().parent)

            expect.falsy(p:descendant_of(c))
            expect.falsy(c:ancestor_of(p))
            expect.truthy(p:ancestor_of(c))
            expect.truthy(c:descendant_of(p))

            p:destroy()
            c:destroy()
        end)

        it('can extend a clone', function()
            local p = pobject(base)
            local c = p:clone(child)
            local g = c:clone(grandchild)

            -- the grandchild should not have any keys
            expect.equal(count_keys(c), 0)

            -- the grandchild should have a locals table with the grandchild values
            expect.exist(g:introspect().locals)
            expect.equal(count_keys(g:introspect().locals), 3)
            expect.equal(g.a, 10)
            expect.equal(g.b, 2)
            expect.equal(g.c, 300)
            expect.equal(g.d, 4)
            expect.equal(g.e, 50)
            expect.equal(g.f, 600)
            expect.equal(g.g, 700)

            expect.equal(c.a, 10)
            expect.equal(c.b, 2)
            expect.equal(c.c, 30)
            expect.equal(c.d, 4)
            expect.equal(c.e, 50)
            expect.equal(c.f, 60)
            expect.not_exist(c.g)

            expect.equal(p.a, 1)
            expect.equal(p.b, 2)
            expect.equal(p.c, 3)
            expect.equal(p.d, 4)
            expect.equal(p.e, 5)
            expect.not_exist(p.f)
            expect.not_exist(p.g)

            -- the grandchild should have a parent
            expect.exist(g:introspect().parent)

            -- the grandchild's parent should be the child
            expect.equal(g:introspect().parent.locals, c:introspect().locals)

            -- the child's parent should match the prototype
            expect.equal(c:introspect().parent.locals, p:introspect().locals)

            p:destroy()
            c:destroy()
            g:destroy()
        end)

        it('relationship predicates work', function()
            local p = pobject()
            local c = p:clone()
            local g = c:clone()
            local c2 = p:clone()
            local g2 = c2:clone()

            expect.truthy(p:ancestor_of(c))
            expect.truthy(c:ancestor_of(g))
            expect.truthy(p:ancestor_of(g))

            expect.truthy(p:ancestor_of(c2))
            expect.truthy(c2:ancestor_of(g2))
            expect.truthy(p:ancestor_of(g2))

            expect.truthy(c:descendant_of(p))
            expect.truthy(g:descendant_of(c))
            expect.truthy(g:descendant_of(p))

            expect.truthy(c2:descendant_of(p))
            expect.truthy(g2:descendant_of(c2))
            expect.truthy(g2:descendant_of(p))

            expect.falsy(p:descendant_of(c))
            expect.falsy(p:descendant_of(g))
            expect.falsy(p:descendant_of(c2))
            expect.falsy(p:descendant_of(g2))

            expect.falsy(c:ancestor_of(p))
            expect.falsy(g:ancestor_of(p))
            expect.falsy(c2:ancestor_of(p))
            expect.falsy(g2:ancestor_of(p))

            expect.falsy(c:ancestor_of(g2))
            expect.falsy(g:ancestor_of(g2))
            expect.falsy(c2:ancestor_of(g))
            expect.falsy(g2:ancestor_of(g))

            expect.falsy(c:descendant_of(g2))
            expect.falsy(g:descendant_of(g2))
            expect.falsy(c2:descendant_of(g))
            expect.falsy(g2:descendant_of(g))

            p:destroy()
            c:destroy()
            g:destroy()
            c2:destroy()
            g2:destroy()
        end)
    end)

    describe('fixing operations', function()
        local base, child, grandchild, p, c, g

        before(function()
            base  =      { a = 1, b = 2, c = 3,    d = 4, e = 5,                    }
            child =      { a = 10,        c = 30,         e = 50, f = 60            }
            grandchild = {                c = 300,                f = 600 , g = 700 }
            p = pobject(base)
            c = p:clone(child)
            g = c:clone(grandchild)
        end)

        after(function()
            base       = nil
            child      = nil
            grandchild = nil
            p:destroy() p = nil
            c:destroy() c = nil
            g:destroy() g = nil
        end)

        it('fixed prototype prohibits changes', function()
            p:fix()
            expect.fail(function()
                p.a = 1000
            end, 'cannot change fixed object')
        end)

        it('fixed child prohibits changes', function()
            c:fix()
            expect.fail(function()
                c.b = 2000
            end, 'cannot change fixed object')
            expect.not_fail(function()
                p.b = 1000
            end)
            expect.equal(c.b, 1000)
        end)

        it("fixed child's unfixed parent permits changes", function()
            c:fix()
            expect.not_fail(function()
                p.b = 2000
            end)
            expect.equal(c.b, 2000)
        end)

    end)

    describe('severing operations', function()
        local base, child, grandchild, p, c, g

        before(function()
            base  =      { a = 1, b = 2, c = 3,    d = 4, e = 5,                    }
            child =      { a = 10,        c = 30,         e = 50, f = 60            }
            grandchild = {                c = 300,                f = 600 , g = 700 }
            p = pobject(base)
            c = p:clone(child)
            g = c:clone(grandchild)
        end)

        after(function()
            base       = nil
            child      = nil
            grandchild = nil
            p:destroy() p = nil
            c:destroy() c = nil
            g:destroy() g = nil
        end)

        it('severed child has same values', function()
            local before = { c.a, c.b, c.c, c.d, c.e }
            c:sever()
            local after = { c.a, c.b, c.c, c.d, c.e }
            expect.equal(before, after)
        end)

        it('severed child has different local values', function()
            local before = c:introspect() before.parent = nil
            c:sever()
            local after = c:introspect()  after.parent = nil
            expect.not_equal(before, after)
        end)

        it('severed child not changed by parent change', function()
            expect.equal(p.a, 1)
            expect.equal(c.a, 10)
            c:sever()
            expect.equal(c.a, 10)
            p.a = 1000
            expect.equal(c.a, 10)
        end)

    end)

    describe('destroying operations', function()
        local base

        before(function()
            base  =      { a = 1, b = 2, c = 3,    d = 4, e = 5,                    }
        end)

        after(function()
            base       = nil
        end)

        it('cannot be accessed after destruction', function()
            p = pobject(base)
            local v
            v = p.a
            p:destroy()
            expect.fail(function()
                v = p.a
            end)
        end)

        it('cannot be altered after destruction', function()
            p = pobject(base)
            p:destroy()
            expect.fail(function()
                p.a = 1000
            end)
        end)

    end)

    describe('introspection operations', function()
        local base, child, p, c

        local p_introspection = {
            a = 1, b = 2, c = 3, d = 4, e = 5
        }

        local c_introspection = {
            a = 10, c = 30, e = 50, f = 60,
            parent = {
                a = 1, b = 2, c = 3, d = 4, e = 5
            }
        }

        before(function()
            base  = { a = 1, b = 2, c = 3,  d = 4, e = 5,         }
            child = { a = 10,       c = 30,        e = 50, f = 60 }
            p =     pobject(base)
            c =     p:clone(c)
        end)

        after(function()
            base          = nil
            child         = nil
            p:destroy() p = nil
            c:destroy() c = nil
        end)

        it('prototype has expected introspection table', function()
            expect.strict_eq(p:introspect(), p_introspection)
        end)

        it('child has expected introspection table', function()
            expect.strict_eq(c:introspect(), c_introspection)
        end)

    end)

    describe('using mixins', function()
        local base, child, p, c, p_mixin, c_mixin

        before(function()
            base  = { a = 1, b = 2, c = 3,  d = 4, e = 5,         }
            child = { a = 10,       c = 30,        e = 50, f = 60 }
            p_mixin = {
                m1 = function(...) return { 'p.m1', ... } end,
                m2 = function(...) return { 'p.m2', ... } end,
            }
            c_mixin = {
                m2 = function(...) return { 'c.m2', ... } end,
                m3 = function(...) return { 'c.m3', ... } end,
            }
        end)

        after(function()
            base    = nil
            child   = nil
            p_mixin = nil
            c_mixin = nil
            if (p) then p:destroy() p = nil end
            if (c) then c:destroy() c = nil end
        end)

        it('allows a mixin to be added into a base prototype', function()
            p = pobject(base):using(p_mixin)
            expect.equal(p:m1('a', 2), { 'p.m1', p, 'a', 2 })
            expect.equal(p:m2('a', 'b', 3), { 'p.m2', p, 'a', 'b', 3 })
        end)

        it('allows a mixin to be added into a cloned child', function()
            p = pobject(base):using(p_mixin)
            c = p:clone(child):using(c_mixin)
            expect.equal(c.m1('a', 2),           { 'p.m1', 'a', 2 })
            expect.equal(c.m2('a', 'b', 3),      { 'c.m2', 'a', 'b', 3 })
            expect.equal(c.m3('a', 'b', 'c', 4), { 'c.m3', 'a', 'b', 'c', 4 })
        end)

    end)

    describe('using observers', function()
        local base, child, counts, rw, ro

        before(function()
            base  =  { a = 1, b = 2, c = 3,  d = 4, e = 5,         }
            child =  { a = 10,       c = 30,        e = 50, f = 60,
                m = function()
                end
            }
            counts = { reads = {a = 0, b = 0}, writes = {a = 0}, }
            rw = {
                get = function(_, k,v) counts.reads[k] = counts.reads[k] + 1 end,
                set = function(_, k,v) counts.writes[k] = counts.writes[k] + 1 end,
            }
            ro = {
                get = function(_, k,v) counts.reads[k] = counts.reads[k] + 1 end,
            }
        end)

        after(function()
            base   = nil
            child  = nil
            counts = nil
            rw     = nil
            ro     = nil
        end)

        it('monitors reads and writes on keys', function()
            local c = pobject(base):clone(child):observe { props = { a = rw, b = ro, } }
            local v
            for _ = 1, 10 do v = c.a end
            for _ = 1, 15 do c.a = v end
            for _ = 1, 3  do v = c.b end
            for _ = 1, 6  do c.b = v end
            expect.equal(counts.reads.a, 10)
            expect.equal(counts.reads.b, 3)
            expect.equal(counts.writes.a, 15)
            expect.not_exist(counts.writes.b)   -- we didn't ask to count writes to b
            c:destroy()
        end)

        it('monitors function calls but not variable accesses', function()
            local precount = 0
            local postcount = 0
            local pp = {
                pre = function() precount = precount + 1 end,
                post = function() postcount = postcount + 1 end,
            }
            local c = pobject(base):clone(child):observe { methods = { m = pp, a = pp } }

            local v
            for _ = 1, 10 do c.m('foo') end
            expect.equal(precount, 10)
            expect.equal(postcount, 10)
            precount = 0
            postcount = 0
            for _ = 1, 10 do v = c.a end
            for _ = 1, 10 do c.a = v end
            expect.equal(precount, 0)
            expect.equal(postcount, 0)
            c:destroy()
        end)

    end)

end)

--[[####################################
  proxify tests
--####################################]]
describe('proxify', function()
    local proxify

    before(function()
        proxify = require 'proxify'
    end)

    after(function()
        package.loaded['proxify'] = nil
        pobject = nil
    end)

    describe('general wrapper proxy', function()

        local data

        before(function()
            data = { a = 5, b = 10, c = 20, d = 40 }
        end)

        it('properly wraps a table', function()
            local last_i = {}
            local last_n = {}

            local i = function(t, k)
                last_i[k] = true
                return t[k]
            end

            local n = function(t, k, v)
                last_n[k] = v
                t[k] = v
            end

            local wt = proxify.wrap(data, i, n)
            expect.not_exist(rawget(wt, 'a'))
            expect.not_exist(rawget(wt, 'b'))
            expect.not_exist(rawget(wt, 'c'))
            expect.not_exist(rawget(wt, 'd'))
            expect.equal(wt.a, 5)
            expect.truthy(last_i.a)
            wt.b = 2
            expect.equal(last_n.b, 2)
        end)

        it('will protect tables from alteration with the ro() wrapper', function()
            local wt = proxify.ro(data)
            expect.not_fail(function()
                local a = wt.a
            end)
            expect.fail(function()
                wt.a = 0
            end)
        end)

        it('will log table access with the logger() wrapper', function()
            local v

            local cb = function(...)
                v = { ... }
            end

            local wt = proxify.logger(data, cb)
            local a = wt.a
            expect.equal(v[1], 'get')
            expect.strict_eq(v[2], wt)
            expect.equal(v[3], 'a')
            expect.equal(v[4], 5)
            wt.a = 1
            expect.equal(v[1], 'set')
            expect.strict_eq(v[2], wt)
            expect.equal(v[3], 'a')
            expect.equal(v[4], 1)
        end)

        it('will count all keys with the counter() wrapper', function()
            local wt = proxify.counter({})
            expect.equal(#wt, 0)
            wt.a = 1
            expect.equal(#wt, 1)
            table.insert(wt, 2)
            expect.equal(#wt, 2)
            wt.a = nil
            expect.equal(#wt, 1)
        end)

    end)

end)

--[[####################################
  rexcept tests
--####################################]]
describe('rexcept', function()
    local rexcept
    local abort, exception, raise, retry, throw, try

    before(function()
        rexcept = require 'rexcept'
        abort, exception, raise, retry, throw, try = rexcept.abort, rexcept.exception, rexcept.raise, rexcept.retry, rexcept.throw, rexcept.try
    end)

    after(function()
        package.loaded['rexcept'] = nil
        rexcept = nil
    end)

    it('executes non-erroring code', function()
        try(function()
            expect.truthy(true)
        end)
        expect.truthy(true)
    end)

    it('passes to end of block on legacy errors', function()
        try(function()
            expect.truthy(true)
            error 'oopsie!'
            expect.truthy(false)    -- if this executes we failed!
        end)
        expect.truthy(true)
    end)

    it('calls the legacy handler on error() calls', function()
        local flag = false
        try(function()
            expect.truthy(true)
            error 'oopsie!'
            expect.truthy(false)    -- if this executes we failed!
        end, {
            legacy = function(...)
                args = { ... }
                expect.equal(string.match(args[1], '^.+(oopsie!)$'), 'oopsie!')
                flag = true
            end,
        })
        expect.truthy(flag)
    end)

    it('lets exceptions be thrown without a handler', function()
        local oopsie = exception('oopsie')
        try(function()
            expect.truthy(true)
            throw(oopsie { 'we dun fucked up!' })
            expect.truthy(false)    -- if this executes we failed!
        end)
        expect.truthy(true)
    end)

    it('lets exceptions be thrown with a handler', function()
        local flag = false
        local oopsie = exception('oopsie')
        try(function()
            expect.truthy(true)
            throw(oopsie { 'we dun fucked up!' })
            expect.truthy(false)    -- if this executes we failed!
        end, {
            oopsie = function(exc)
                flag = true
                expect.equal(tostring(exc), "oopsie{data={1='we dun fucked up!',},")
            end,
        })
        expect.truthy(flag)
    end)

    it('lets exceptions be raised without a handler', function()
        local oopsie = exception('oopsie')
        try(function()
            expect.truthy(true)
            raise(oopsie { 'we dun fucked up!' })
            expect.truthy(false)    -- if this executes we failed!
        end)
        expect.truthy(true)
    end)

    it('lets exceptions be raised with an aborting handler', function()
        local flag = false
        local oopsie = exception('oopsie')
        try(function()
            raise(oopsie { 'we dun fucked up!' })
            expect.truthy(false)    -- if this executes we failed!
        end, {
            oopsie = function(exc)
                flag = true
                expect.equal(tostring(exc), "oopsie{data={1='we dun fucked up!',},")
                return abort()
            end,
        })
        expect.truthy(flag)
    end)

    it('lets exceptions be raised with a restarting handler', function()
        local a, b, c
        local state = 0
        local oopsie = exception('oopsie')
        try(function()
            expect.equal(state, 0)
            a, b, c = raise(oopsie { 'we dun fucked up!' })
            expect.equal(state, 1)
            expect.equal(a, 1)
            expect.equal(b, 2)
            expect.equal(c, 3)
        end, {
            oopsie = function(exc)
                state = 1
                expect.equal(tostring(exc), "oopsie{data={1='we dun fucked up!',},")
                return retry(1, 2, 3)
            end,
        })
        expect.equal(state, 1)
    end)

    it('lets exceptions be raised with a restarting handler before throwing another', function()
        local a, b, c
        local state = 0
        local oopsie = exception('oopsie')
        local foobar = exception('foobar')
        try(function()
            expect.equal(state, 0)
            a, b, c = raise(oopsie { 'we dun fucked up!' })
            expect.equal(state, 1)
            expect.equal(a, 1)
            expect.equal(b, 2)
            expect.equal(c, 3)
            throw(foobar { 'we REALLY dun fucked up!' })
            expect.truthy(false)    -- if this executes we failed!
        end, {
            oopsie = function(exc)
                state = 1
                expect.equal(tostring(exc), "oopsie{data={1='we dun fucked up!',},")
                return retry(1, 2, 3)
            end,
            foobar = function(exc)
                state = 2
                expect.equal(tostring(exc), "foobar{data={1='we REALLY dun fucked up!',},")
            end,
        })
        expect.equal(state, 2)
    end)

    it('catches exceptions through a call stack', function()
        local a, b, c
        local state = 0
        local oopsie = exception('oopsie')
        local foobar = exception('foobar')

        local function restartable()
            expect.equal(state, 0)
            a, b, c = raise(oopsie { 'we not worthy!' })
            expect.equal(state, 1)
            expect.equal(a, 1)
            expect.equal(b, 2)
            expect.equal(c, 3)
        end

        local function non_restartable()
            expect.equal(state, 1)
            throw(foobar { 'we REALLY dun fucked up!' })
            expect.truth(false) -- we shouldn't get here
        end

        try(function()
            restartable()
            non_restartable()
            expect.truthy(false)    -- if this executes we failed!
        end, {
            oopsie = function(exc)
                state = 1
                expect.equal(tostring(exc), "oopsie{data={1='we not worthy!',},")
                return retry(1, 2, 3)
            end,
            foobar = function(exc)
                state = 2
                expect.equal(tostring(exc), "foobar{data={1='we REALLY dun fucked up!',},")
            end,
        })
        expect.equal(state, 2)
    end)

end)

--[[####################################
  set tests
--####################################]]
describe('set', function()
    local set

    before(function()
        set = require 'set'
    end)

    after(function()
        package.loaded['set'] = nil
        set = nil
    end)

    describe('fundamental operations', function()

        it('makes an empty set', function()
            local s = set()
            expect.equal(#s, 0)
        end)

        it('makes an initialized set', function()
            local s = set('a', 'b', 'c', 'd')
            expect.truthy(s:has('a'))
            expect.truthy(s:has('b'))
            expect.truthy(s:has('c'))
            expect.truthy(s:has('d'))
            expect.falsy(s:has('e'))
        end)

        it('permits set insertion', function()
            local s = set('a', 'b', 'c', 'd')
            expect.equal(#s, 4)
            expect.falsy(s:has('e'))
            s:insert('e')
            expect.equal(#s, 5)
            expect.truthy(s:has('e'))
            expect.falsy(s:has('f'))
            expect.falsy(s:has('g'))
            s:batch_insert('f', 'g')
            expect.equal(#s, 7)
            expect.truthy(s:has('f'))
            expect.truthy(s:has('g'))
        end)

        it('permits repeated set insertion', function()
            local s = set('a', 'b', 'c', 'd')
            expect.equal(#s, 4)
            s:insert('d')
            expect.equal(#s, 4)
        end)

        it('permits set removal', function()
            local s = set('a', 'b', 'c', 'd')
            expect.equal(#s, 4)
            s:remove('b')
            expect.equal(#s, 3)
            expect.truthy(s:has('a'))
            expect.falsy(s:has('b'))
            expect.truthy(s:has('c'))
            expect.truthy(s:has('d'))
        end)

        it('permits batch set removal', function()
            local s = set('a', 'b', 'c', 'd')
            expect.equal(#s, 4)
            s:batch_remove('b', 'c')
            expect.equal(#s, 2)
            expect.truthy(s:has('a'))
            expect.falsy(s:has('b'))
            expect.falsy(s:has('c'))
            expect.truthy(s:has('d'))
        end)

        it('easily permits cloning with standard table operations', function()
            local s1 = set('a', 'b', 'c', 'd')
            local s2 = set(table.unpack(s1))
            expect.equal(#s1, 4)
            expect.equal(#s2, 4)
            for k, v in ipairs(s1) do
                expect.equal(s1[k], s2[k])
            end
        end)

    end)

    describe('operators', function()

        it('performs set unions', function()
            local s1 = set('a', 'b', 'c')
            local s2 = set('c', 'd', 'e')
            local s3 = s1 + s2
            expect.equal(#s1, 3)
            expect.equal(#s2, 3)
            expect.equal(#s3, 5)
            expect.truthy(s3:has('a'))
            expect.truthy(s3:has('b'))
            expect.truthy(s3:has('c'))
            expect.truthy(s3:has('d'))
            expect.truthy(s3:has('e'))
        end)

        it('performs set differences', function()
            local s1 = set('a', 'b', 'c')
            local s2 = set('c', 'd', 'e')
            local s3 = s1 - s2
            expect.equal(#s1, 3)
            expect.equal(#s2, 3)
            expect.equal(#s3, 2)
            expect.truthy(s3:has('a'))
            expect.truthy(s3:has('b'))
            expect.falsy(s3:has('c'))
            expect.falsy(s3:has('d'))
            expect.falsy(s3:has('e'))
        end)

        it('performs set intersections', function()
            local s1 = set('a', 'b', 'c')
            local s2 = set('c', 'd', 'e')
            local s3 = s1 * s2
            expect.equal(#s1, 3)
            expect.equal(#s2, 3)
            expect.equal(#s3, 1)
            expect.falsy(s3:has('a'))
            expect.falsy(s3:has('b'))
            expect.truthy(s3:has('c'))
            expect.falsy(s3:has('d'))
            expect.falsy(s3:has('e'))
        end)

        it('performs set equality', function()
            local s1 = set('a', 'b', 'c')
            local s2 = set('c', 'a', 'b')
            expect.equal(#s1, 3)
            expect.equal(#s2, 3)
            expect.strict_eq(s1, s2)
        end)

        it('performs subsets and proper subsets', function()
            local s1 = set('a', 'b', 'c')
            local s2 = set('a', 'b', 'c')
            local s3 = set('a', 'b')
            expect.equal(#s1, 3)
            expect.equal(#s2, 3)
            expect.equal(#s3, 2)
            expect.falsy(s1 < s2)
            expect.truthy(s1 <= s2)
            expect.truthy(s3 < s2)
            expect.truthy(s3 <= s2)
        end)

    end)

end)

--[[####################################
  stringx tests
--####################################]]
describe('stringx', function()
    local string
    local clean = 'Hello there!'
    local dirty = 'Ĥełlő tħeŕe!'
    local cleaned = 'el tee!'
    local ascii_span = '[\0-\x7f]+'

    before(function()
        string = require 'stringx'
    end)

    after(function()
        package.loaded['stringx'] = nil
        string = nil
    end)

    it('extends the string library transparently', function()
        expect.equal(string.format('test %d', 1), 'test 1')
    end)

    it('is an empty table, functionality concealed behind a metatable', function()
        expect.strict_eq(table, {})
    end)

    it('concatenates with a separator', function()
        local s = string.concat({'a', 'b', 'c', 'd'}, ',')
        expect.equal(s, 'a,b,c,d')
    end)

    it('counts string subsets matching a pattern', function()
        expect.equal(string.count_matching(clean, ascii_span), 12)
        expect.equal(string.count_matching(dirty, ascii_span), 7)
    end)

    it('extracts string subsets matching a pattern', function()
        expect.equal(string.extract_matching(clean, ascii_span), clean)
        expect.equal(string.extract_matching(dirty, ascii_span), cleaned)
    end)

    it('splits a string by a supplied pattern', function()
        expect.equal(string.split('a,b,c,d', ','), {'a', 'b', 'c', 'd'})
    end)

end)

--[[####################################
  tablex tests
--####################################]]
describe('tablex', function()
    local table

    before(function()
        table = require 'tablex'
    end)

    after(function()
        package.loaded['tablex'] = nil
        table = nil
    end)

    it('extends the table library transparently', function()
        local t = {}
        table.insert(t, 1)
        expect.equal(t, {1})
    end)

    it('is an empty table, functionality concealed behind a metatable', function()
        expect.strict_eq(table, {})
    end)

    it('dumps a table into a string for display or processing', function()
        local test_table = {
            a = 5,
            f = 3,
            g = 17,
            [5] = { 'what', 'do', 'you', 'want', '?' },
        }
        local expected = string.format('test_table -> table: %p\n' ..
                                       '  5 -> table: %p\n' ..
                                       '    1 -> what\n' ..
                                       '    2 -> do\n' ..
                                       '    3 -> you\n' ..
                                       '    4 -> want\n' ..
                                       '    5 -> ?\n' ..
                                       '  a -> 5\n' ..
                                       '  f -> 3\n' ..
                                       '  g -> 17\n', test_table, test_table[5])
        expect.equal(expected, table.dump(test_table, 'test_table'))
    end)

    it('iterates over a table key set, calling a function for each one', function()
        local t1 = { a = 1, b = 2, c = 3, d = 4, e = 5 }
        local t2 = {}
        table.foreach(t1, function(k, v)
            t2[k] = v
        end)
        expect.equal(t1, t2)
    end)

    it('can count the number of matching elements', function()
        local t = { a = 1, b = 2, c = 3, d = 1, e = 2, f = 1 }
        expect.equal(table.count(t, 1), 3)
        expect.equal(table.count(t, 2), 2)
        expect.equal(table.count(t, 3), 1)
    end)

    it('can find a matching element, returning its key', function()
        local t = { a = 1, b = 2, c = 3, d = 1, e = 2, f = 1 }
        k = table.find(t, 2)
        expect.truthy(k == 'b' or k == 'e')
    end)

    it('can count all keys, not just numerical', function()
        local t = { [1] = 'a', [2] = 'b', c = 3 , d = 4, e = 5 }
        expect.equal(#t, 2)
        expect.equal(table.size(t), 5)
    end)

    it('can slice array-flavoured lists', function()
        local t = { 'a', 'b', 'c', 'd', 'e', 'f', 'g' }
        expect.equal(table.slice(t, 1, 2), {'b', 'c', 'd', 'e'})
        expect.equal(table.skip(t, 1), {'b', 'c', 'd', 'e', 'f', 'g'})
        expect.equal(table.drop(t, 1), {'a', 'b', 'c', 'd', 'e', 'f'})
    end)

    it('can apply higher-order functions to tables', function()
        local t1 = { a = 1, b = 2, c = 3, d = 1, e = 2, f = 1 }
        local t2 = table.map(t1, function(v) return v * 2 end)
        expect.equal(t2, { a = 2, b = 4, c = 6, d = 2, e = 4, f = 2 })
        local t3 = table.filter(t1, function(k, v, ...) return v == 2 end)
        expect.equal(t3, { b = 2, e = 2 })
    end)

    -- I'm genuinely unsure how to test the shuffle functions

end)

--[[####################################
  tag tests
--####################################]]
describe('tag', function()
    local tag

    before(function()
        tag = require 'tag'
    end)

    after(function()
        package.loaded['tag'] = nil
        tag = nil
    end)

    it('can tag a table', function()
        local t1 = { 1, 2, 3, 4, 5 }
        expect.falsy(tag.has_tag(t1, 'tag you are it'))
        tag.add(t1, 'tag you are it')
        expect.truthy(tag.has_tag(t1, 'tag you are it'))
    end)

    it('can tag a table with multiple tags', function()
        local t1 = { 1, 2, 3, 4, 5 }
        expect.falsy(tag.has_tag(t1, 'tag you are it'))
        expect.falsy(tag.has_tag(t1, 'no you are'))
        tag.add(t1, 'tag you are it')
        expect.truthy(tag.has_tag(t1, 'tag you are it'))
        expect.falsy(tag.has_tag(t1, 'no you are'))
        tag.add(t1, 'no you are')
        expect.truthy(tag.has_tag(t1, 'tag you are it'))
        expect.truthy(tag.has_tag(t1, 'no you are'))
    end)

    it('can untag a table', function()
        local t1 = { 1, 2, 3, 4, 5 }
        tag.add(t1, 'tag you are it')
        tag.add(t1, 'no you are')
        expect.truthy(tag.has_tag(t1, 'tag you are it'))
        expect.truthy(tag.has_tag(t1, 'no you are'))
        tag.remove(t1, 'tag you are it')
        expect.falsy(tag.has_tag(t1, 'tag you are it'))
        expect.truthy(tag.has_tag(t1, 'no you are'))
        tag.remove(t1, 'no you are')
        expect.falsy(tag.has_tag(t1, 'tag you are it'))
        expect.falsy(tag.has_tag(t1, 'no you are'))
    end)

end)

--[[####################################
  utils tests
--####################################]]
describe('utils', function()
    local utils

    before(function()
        utils = require 'utils'
    end)

    after(function()
        package.loaded['utils'] = nil
        utils = nil
    end)

    it('can round numbers to various significant digits', function()
        local v = 12345
        expect.equal(utils.significant_digits(v, 1), 10000)
        expect.equal(utils.significant_digits(v, 2), 12000)
        expect.equal(utils.significant_digits(v, 3), 12300)
        expect.equal(utils.significant_digits(v, 4), 12350)
        expect.equal(utils.significant_digits(v, 5), 12345)
    end)

    it('can round fractions to various significant digits', function()
        local v = 12345.6789
        -- because floats are not exact matches, we use a 'within reasonable distance' criterion
        expect.falsy(math.abs(utils.significant_digits(v, 1)  - 10000.0)    / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 2)  - 12000.0)    / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 3)  - 12300.0)    / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 4)  - 12350.0)    / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 5)  - 12346.0)    / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 6)  - 12345.7)    / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 7)  - 12345.68)   / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 8)  - 12345.679)  / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 9)  - 12345.6789) / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 10) - 12345.6789) / v > 0.000000000000001)

        v = 0.00012345
        expect.falsy(math.abs(utils.significant_digits(v, 1) - 0.0001)    / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 2) - 0.00012)   / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 3) - 0.000123)  / v > 0.000000000000001)
        expect.falsy(math.abs(utils.significant_digits(v, 4) - 0.0001235) / v > 0.000000000000001)
    end)

end)

lester.report()
lester.exit()
